﻿using System;
using System.Collections.Generic;
using System.Data;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using OverStore.Runtime.Logger;
using OverStore.Scoping;
using OverStore.Scoping.Manager;
using OverStore.Sql;

namespace OverStore.Runtime.Storage
{
    /// <summary>
    /// Wrapper over database or other ADO.NET compatible data source.
    /// Manages connection and uses <see cref="OverStore.Scoping.Manager.OperationScopeManager"/>
    /// for control transactions.
    /// </summary>
    /// <typeparam name="TConnection">The type of the connection to data source.</typeparam>
    /// <remarks>
    /// Resource management. 
    /// AdoNetStorage operates few types of managed resources. 
    /// It is database-related resources: connections, database transactions, commands, data readers,
    /// and another one - operation scope, represents logical transactions.
    /// 
    /// Resource allocation schema:
    /// 
    /// 1..1 Operation Scope
    ///     0..1 Connection
    ///        0..1 Transaction
    ///             0..N Commands
    ///             0..M Data Readers
    ///             
    /// 1 Root Operation Scope
    ///     0..1 Connection
    ///         0 Transaction
    ///         0 Commands
    ///         1..N Data Readers
    /// 
    /// Operation scope is the main unit of resource management. 
    /// All database operation are bound to specific operation scope.
    /// Some session operations internally starts new operation scopes. 
    /// Object Readers are bound to current operation scope or to root operation scope.
    /// 
    /// 
    /// </remarks>
    public sealed class AdoNetStorage<TConnection> : IDataStorage, IDisposable
        where TConnection : IDbConnection
    {
        private const string LogCategory = "ADONETSTORAGE";
        private readonly string RootScope = "< root scope >";
        private readonly string _ConnectionString;
        private readonly ILogger _Logger;
        private readonly ProcessOpenedReadersMode _CommitWithOpenedReaders = ProcessOpenedReadersMode.ForciblyCloseReaders;

        private bool _Disposed = false;
        private readonly OperationScopeManager _OperationScopeManager;
        private readonly Dictionary<object, IDbTransaction> ScopeConnectionPool = new Dictionary<object, IDbTransaction>();
        private readonly List<DataCommandContainer> _Commands = new List<DataCommandContainer>();
        private readonly Dictionary<IDbConnection, Dictionary<AdoNetCommandBase, IDbCommand>> _DbCommandReuseCache = new Dictionary<IDbConnection, Dictionary<AdoNetCommandBase, IDbCommand>>();
        private readonly SqlFormatting _SqlFormatting;

        /// <summary>
        /// Initializes a new instance of the <see cref="AdoNetStorage{TConnectionType}"/> class.
        /// </summary>
        /// <param name="connectionString">The connection string used for connect to actual data storage.</param>
        /// <param name="commitWithOpenReadersMode">Defines behavior on transaction commit if opened readers exist.</param>
        /// <param name="logger">The logger uses for output diagnostics information.</param>
        /// <param name="sqlFormatting">The SQL formatting.</param>
        /// <param name="scopeManager">The operation scope manager.</param>
        /// <exception cref="ArgumentNullException"><paramref name="connectionString"/> is null or empty.</exception>
        public AdoNetStorage(string connectionString, ProcessOpenedReadersMode commitWithOpenReadersMode, ILogger logger, SqlFormatting sqlFormatting, OperationScopeManager scopeManager)
        {
            if (connectionString == null)
                throw new ArgumentNullException("connectionString");
            if (Object.ReferenceEquals(scopeManager, null))
                throw new ArgumentNullException("scopeManager");
            _OperationScopeManager = scopeManager;
            _ConnectionString = connectionString;
            _Logger = logger;
            _CommitWithOpenedReaders = commitWithOpenReadersMode;
            _OperationScopeManager.ScopeExited += OnScopeExited;
            _SqlFormatting = sqlFormatting;
        }

        /// <summary>
        /// Gets the SQL formatting used for generate SQL commands using table schema or null if storage is not SQL based storage or
        /// it does not support generated SQL.
        /// </summary>
        public SqlFormatting SqlFormatting
        {
            get
            {
                return _SqlFormatting;
            }
        }

        /// <summary>
        /// Executes command and reads all data from returned resultset.
        /// </summary>
        /// <param name="command">The command.</param>
        /// <param name="parameters">The parameters.</param>
        public IEnumerable<TypedRecord> ReadAll(AdoNetCommandBase command, IRecord parameters)
        {
            if (command == null)
                throw new ArgumentNullException("command");
            if (parameters == null)
                throw new ArgumentNullException("parameters");
            if (_Disposed)
                throw new ObjectDisposedException("AdoNetStorage");
            return command.ReadAll(CreateCommand, parameters);
        }

        /// <summary>
        /// Gets <c>true</c> if instance was disposed, <c>false</c> otherwise.
        /// </summary>
        public bool Disposed
        {
            get
            {
                return _Disposed;
            }
        }

        /// <summary>
        /// Executes the specified command and return execution result.
        /// </summary>
        /// <param name="command">The command.</param>
        /// <param name="parameters">The parameters.</param>
        /// <param name="rowsAffected">Number of rows affected.</param>
        public TypedRecord Execute(AdoNetCommandBase command, IRecord parameters, out int rowsAffected)
        {
            if (command == null)
                throw new ArgumentNullException("command");
            if (parameters == null)
                throw new ArgumentNullException("parameters");
            if (_Disposed)
                throw new ObjectDisposedException("AdoNetStorage");
            return command.Execute(CreateCommand, parameters, out rowsAffected);
        }

        TypedRecord IDataStorage.Execute(ICommand command, IRecord parameters, out int rowsAffected)
        {
            var adoCommand = command as AdoNetCommandBase;
            if (adoCommand == null)
                throw new ArgumentException("Command argument has wrong type.");
            if (_Disposed)
                throw new ObjectDisposedException("AdoNetStorage");
            return Execute(adoCommand, parameters, out rowsAffected);
        }

        IEnumerable<TypedRecord> IDataStorage.ReadAll(ICommand command, IRecord parameters)
        {
            var adoCommand = command as AdoNetCommandBase;
            if (adoCommand == null)
                throw new ArgumentException("Command argument has wrong type.");
            return ReadAll(adoCommand, parameters);
        }

        /// <summary>
        /// Starts new operation scope.
        /// </summary>
        public OperationScope OpenScope()
        {
            return _OperationScopeManager.OpenScope();
        }

        private DataCommandContainer CreateCommand(AdoNetCommandBase adoNetCommandBase)
        {
            if (_Disposed)
                throw new ObjectDisposedException("AdoNetStorage");
            var scope = _OperationScopeManager.Current ?? RootScope;
            IDbConnection connection = null;
            IDbTransaction transaction = null;
            if (ScopeConnectionPool.TryGetValue(scope, out transaction))
                connection = transaction.Connection;
            else
            {
                connection = (IDbConnection)Activator.CreateInstance(typeof(TConnection));
                connection.ConnectionString = _ConnectionString;
                connection.Open();
                Log("Connection {0} is opened on scope {1}.", connection.GetHashCode(), scope);

                transaction = connection.BeginTransaction();
                ScopeConnectionPool[scope] = transaction;
                Log("Transaction {0} is started on scope {1}", transaction.GetHashCode(), scope);
            }

            DataCommandContainer result;

            if (_DbCommandReuseCache.ContainsKey(connection) &&
                _DbCommandReuseCache[connection].ContainsKey(adoNetCommandBase))
            {
                var command = _DbCommandReuseCache[connection][adoNetCommandBase];
                Log("Command {0} is extracted from reuse cache.", command.GetHashCode());
                result = new DataCommandContainer(command, scope, CreateReaderForDataContainer, true);
            }
            else
            {
                var command = connection.CreateCommand();
                Log("Command {0} is created.", command.GetHashCode());
                command.Transaction = transaction;
                result = new DataCommandContainer(command, scope, CreateReaderForDataContainer, false);

                if (!_DbCommandReuseCache.ContainsKey(connection))
                    _DbCommandReuseCache[connection] = new Dictionary<AdoNetCommandBase, IDbCommand>();
                _DbCommandReuseCache[connection][adoNetCommandBase] = command;
            }
            _Commands.Add(result);
            result.Disposing += OnDataCommandContainerDisposing;
            return result;
        }

        private IDataReader CreateReaderForDataContainer(DataCommandContainer c)
        {
            try
            {
                var r = c.Command.ExecuteReader();
                Log("Data reader {0} is started on scope {1}.", r.GetHashCode(), c.OperationScope);
                return r;
            }
            catch (Exception ex)
            {
                throw new PersistingRuntimeException("Ado.Net command error: opening data reader failed.", ex);
            }
        }

        /// <summary>
        /// Disposes data reader if any and data command of specified container.
        /// </summary>
        private void DisposeDataCommandContainer(DataCommandContainer container)
        {
            Debug.Assert(container != null, "container != null");
            container.Disposing -= OnDataCommandContainerDisposing;
            if (container.IsDataReaderOpened)
            {
                Log("Data reader {0} is disposed.", container.DataReader.GetHashCode());
                if (!container.DataReader.IsClosed)
                    container.DataReader.Close();
                container.DataReader.Dispose();
            }
        }

        /// <summary>
        /// Disposes the cached commands for the specified connection.
        /// </summary>
        private void DisposeCachedCommands(IDbConnection connection)
        {
            Debug.Assert(connection != null, "connection != null");
            Dictionary<AdoNetCommandBase, IDbCommand> reuseCache;
            if (!_DbCommandReuseCache.TryGetValue(connection, out reuseCache))
                return;
            _DbCommandReuseCache.Remove(connection);
            foreach (var command in reuseCache.Values)
            {
                Log("Command {0} contains in reuse cache is disposed.", command.GetHashCode());
                command.Dispose();
            }
        }

        /// <summary>
        /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
        /// </summary>
        public void Dispose()
        {
            if (!_Disposed)
            {
                _Disposed = true;
                Log("Disposing ADO.NET Storage.");

                _OperationScopeManager.ScopeExited -= OnScopeExited;
                foreach (var command in _Commands)
                    command.Disposing -= OnDataCommandContainerDisposing;

                var openedReadersCount = _Commands.Where(c => c.IsDataReaderOpened).Count();
                Log("There are {0} opened readers.", openedReadersCount);

                Log("There are {0} uncompleted commands. Disposing.", _Commands.Count);
                List<IDbConnection> disposedConnections = new List<IDbConnection>();
                List<IDbTransaction> disposedTransactions = new List<IDbTransaction>();
                foreach (var c in _Commands)
                    DisposeDataCommandContainer(c);

                foreach (var c in _Commands)
                {
                    if (c.Transaction != null && !disposedTransactions.Contains(c.Transaction))
                    {
                        Log("Transaction {0} is disposed.", c.Transaction.GetHashCode());
                        disposedTransactions.Add(c.Transaction);
                        c.Transaction.Rollback();
                        c.Transaction.Dispose();
                    }

                    if (!disposedConnections.Contains(c.Connection))
                    {
                        Log("Connection {0} is disposed.", c.Connection.GetHashCode());
                        disposedConnections.Add(c.Connection);
                        c.Connection.Close();
                        c.Connection.Dispose();
                    }
                }
                foreach (var c in ScopeConnectionPool.Values)
                {
                    var transactionDisposed = disposedTransactions.Contains(c);
                    if (!transactionDisposed)
                    {
                        Log("Transaction {0} is not disposed by referenced command. Disposing.", c.GetHashCode());
                        var referencedConnection = c.Connection;
                        c.Dispose();

                        if (!disposedConnections.Contains(referencedConnection))
                        {
                            Log("Connection {0} is not disposed by referenced command. Disposing.", referencedConnection.GetHashCode());
                            referencedConnection.Dispose();
                            disposedConnections.Add(referencedConnection);
                        }
                    }
                }
                disposedTransactions.Clear();
                disposedConnections.Clear();
                ScopeConnectionPool.Clear();
            }
        }

        private void OnDataCommandContainerDisposing(object sender, EventArgs e)
        {
            var command = sender as DataCommandContainer;
            Debug.Assert(command != null, "command != null");

            var removed = _Commands.Remove(command);
            Debug.Assert(removed, "Command is not registered.");
            DisposeDataCommandContainer(command);

            // Root scope will be never exited, so if this is last command 
            // in the root scope, remove connection and transaction in the scope.
            if (!Equals(command.OperationScope, RootScope))
                return;

            var rootBoundCommands = _Commands.Where(c => Equals(c.OperationScope, RootScope))
                .ToArray();
            if (rootBoundCommands.Count() == 0)
            {
                Log("Command bound to root scope is completed and no more command is bound to root scope. Dispose transaction and connection.");
                ScopeConnectionPool.Remove(RootScope);
                if (command.Transaction != null)
                {
                    Log("Transaction {0} is disposed.", command.Transaction.GetHashCode());
                    command.Transaction.Dispose();
                }
                if (command.Connection != null)
                {
                    var connection = command.Connection;
                    DisposeCachedCommands(connection);
                    Log("Connection {0} is disposed.", connection.GetHashCode());
                    connection.Dispose();
                }
            }
            else
                Log("Command bound to root scope is completed and {0} commands still bounds to root scope.", rootBoundCommands.Count());
        }

        private void OnScopeExited(object sender, OperationScopeActionEventArgs e)
        {
            if (e.Action == OperationScopeAction.PostFailing ||
                e.Action == OperationScopeAction.PostCompleting)
                return;

            Log("Scope {0} is {1}.", e.CurrentScope, e.Action);
            IDbTransaction transaction = null;
            if (!ScopeConnectionPool.TryGetValue(e.CurrentScope, out transaction))
            {
                Log("Scope {0} has no database transaction assosiated.", e.CurrentScope);
                return;
            }
            ScopeConnectionPool.Remove(e.CurrentScope);
            try
            {
                var readers = _Commands.Where(c => c.Transaction == transaction && c.IsDataReaderOpened).ToArray();
                if (readers.Length != 0)
                {
                    switch (_CommitWithOpenedReaders)
                    {
                        case ProcessOpenedReadersMode.ThrowException:
                            {
                                foreach (var r in readers)
                                    DisposeDataCommandContainer(r);
                                throw new PersistingRuntimeException(
                                            String.Format(
                                                CultureInfo.InvariantCulture,
                                                "Ado.Net Storage error: transaction ending failed: there are {0} data readers opened inside transaction.",
                                                readers.Count()));
                            }
                        case ProcessOpenedReadersMode.ForciblyCloseReaders:
                            Log("There are {0} opened reader running in scope {1} will be forcible closed.",
                                readers.Count(),
                                e.CurrentScope);
                            foreach (var r in readers)
                                DisposeDataCommandContainer(r);
                            break;
                        case ProcessOpenedReadersMode.WaitForReadersClosing:
                            throw new NotImplementedException();
                    }
                }
                if (e.Action == OperationScopeAction.Completing)
                {
                    transaction.Commit();
                    Log("Transaction {0} is commited on scope {1}", transaction.GetHashCode(), e.CurrentScope);
                }
                else
                {
                    transaction.Rollback();
                    Log("Transaction {0} is rolled back on scope {1}", transaction.GetHashCode(), e.CurrentScope);
                }
            }
            catch (Exception ex)
            {
                throw new PersistingRuntimeException("Ado.Net Storage error: ending transaction failed.", ex);
            }

            _Commands.RemoveAll(c => c.OperationScope == e.CurrentScope);
            var connection = transaction.Connection;

            Log("Transaction {0} is disposed.", transaction.GetHashCode());
            transaction.Dispose();

            if (connection != null)
            {
                DisposeCachedCommands(connection);
                Log("Connection {0} is disposed.", connection.GetHashCode());
                connection.Dispose();
            }
        }

        private void Log(string formatString, params object[] arguments)
        {
            if (_Logger != null)
                _Logger.Log(this, LogCategory, String.Format(CultureInfo.InvariantCulture, formatString, arguments));
        }

        private void Log(string message)
        {
            if (_Logger != null)
                _Logger.Log(this, LogCategory, message);
        }
    }
}
